feat(map): support CARTO basemap API keys - #13
Open
dborup wants to merge 3 commits into
Open
Conversation
From August 2026 CARTO requires a Basemaps API key on raster tile requests; without one the tiles come back stamped "API KEY REQUIRED -- carto.com/basemapsapikey". CoreScope built its CARTO URLs by hand in five different places and never sent a key. Adds map.tiles.providers.carto.token and routes every CARTO surface through one helper, window.MC_getCartoTileUrl(path), which owns the base URL (including the enterprise `domain` override), trims the token and appends ?key=<encodeURIComponent(token)> -- or nothing at all, so a missing token never leaves a bare "?". It takes a tile path only, never a full URL, so no caller can smuggle in its own host or querystring. Surfaces converted: the five registry styles, roles.js' TILE_DARK / TILE_LIGHT (now accessors that re-resolve on every read, which also fixes their load-order and async-config problems), both Customize geo-filter maps, and the standalone geofilter-builder page -- which previously had no config load of its own and now fetches /api/config/client before building its layer. Leaflet requests tiles the moment a layer joins a map, so resolving the token late was not enough on its own: map.js and live.js now build the Auto layer and the layer picker inside one idempotent MC_whenTileConfigReady callback, so the first CARTO request already carries the key instead of caching a watermarked tile. Map creation, panes, zoom and fullscreen controls are unaffected. If the config fetch fails -- or map-tile-providers.js is missing entirely -- everything falls back to the previous keyless behaviour rather than an empty map. Backwards compatible: carto.enabled=false still removes CARTO from the registered styles (the dedicated geo-filter maps keep using it, as the config comment now spells out), and an install with no token behaves exactly as before, just watermarked by CARTO. No real key is committed; config.example.json ships an empty token and documents the YOUR_CARTO_BASEMAP_KEY placeholder. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rden domain Upstream Kpa-clawbot#1919 merged as 7aa60c0 and named the config field `tiles.providers.carto.key`; ours was `carto.token`. Since this branch has never been pushed or deployed there is no migration to carry, so this is a clean rename to the upstream spelling rather than permanent dual support - one config now works on both forks. Also hardens `carto.domain`, which neither implementation validated. It is concatenated straight into the host, so a bad value escapes it: domain "evil.com/x?a=b" -> https://{s}.evil.com/x?a=b.cartocdn.com/dark_all/... -> host a.evil.com, and with a key set the ?key= suffix is sent THERE, so a config value exfiltrates the key '?' or '#' in the value also smuggle a query ahead of our own suffix and produce a second '?'. `domain` now accepts dot-separated DNS labels only (the documented enterprise form, e.g. "mycompany"), is trimmed, and any other value is ignored with one console warning, falling back to the public base. Valid domains are unaffected. This applies to upstream too. Everything else from 8738776 is kept: MC_getCartoTileUrl remains the one place a CARTO URL is built (verified - the only runtime `cartocdn` occurrence left in public/ is inside _getCartoBase itself), the MC_whenTileConfigReady deferral still prevents a keyless first paint on Map and Live, and roles.js / customize-v2.js / geofilter-builder.html still resolve through the helper. Key handling is unchanged: trimmed, whitespace-only treated as absent, encoded exactly once, and an empty or missing key emits the byte-identical pre-key URL. Tests: 64 passed (was 55), 5 consecutive clean runs. Nine new cases cover valid/dotted/trimmed domains, ten host-escaping values that must be ignored, proof the key never reaches an injected host and that exactly one querystring is emitted, domain+key composition across all five styles, and that the legacy `carto.token` field is now inert in code and absent from the example. The example's claim that the key can be "restricted by origin/referrer" is corrected - CARTO Basemaps keys cannot be - and the domain restriction is documented. Upstream's own suite still passes unchanged (33/33). Full frontend sweep over 300 files is identical to the 8738776 baseline (157/143; the 143 are pre-existing, and test-issue-1470 / test-issue-1438 fail on both trees).
Four review findings on top of 2932368. 1. config.example.json asserted "CARTO Basemaps keys cannot be restricted by origin or referrer". I could not document that from CARTO's current basemap-key interface, and the previous wording asserted the opposite just as confidently. Replaced with guidance that holds either way: follow any domain/referrer restrictions offered when CARTO issues the key, use a key dedicated to this deployment, monitor its usage, and rotate it if abused. 2. Finished the token -> key rename in prose. Six comments in map-tile-providers.js plus one each in live.js and roles.js still said "token" while describing carto.key. OSM and Stamen keep "token" - that is their actual field name - and a test now asserts both halves so the rename cannot regress or over-reach. 3. The invalid-domain warning echoed the rejected value. That value is operator input of unknown provenance and the line can reach shared logs or a screenshot, so it now names only the expected form. Still exactly one warning per page, still falling back to the public base. 4. The validator accepted over-long labels: the regex had no length rule, so a 64-character label - or a 300-character value - built a host that is invalid per DNS. Labels are now capped at 63 and the whole value at 238, which keeps "a." + domain + ".cartocdn.com" inside the 253-character host limit. Empty labels are rejected explicitly, which also covers leading/trailing dots and '..' runs. This is URL and misconfiguration hardening, not an authorization boundary: the value comes from the operator's own config.json, and the point is that a typo or a pasted full URL degrades to the public base instead of silently retargeting tiles and the key at another host. Runtime semantics and the config-ready lifecycle are unchanged: MC_getCartoTileUrl is still the single URL builder, MC_whenTileConfigReady still defers the first attach on Map and Live, and key handling (trim, whitespace-only as absent, encoded once, byte-identical keyless URL) is untouched. carto.token stays inert. Tests: 74 passed (was 64), 5 consecutive clean runs. Ten new cases cover the 63/64-character label boundary, an over-long overall value, empty labels and leading/trailing dots and hyphens, an internal hyphen still being valid, the warning never reprinting the rejected value (asserted with a value carrying a fake secret), at most one warning, no warning at all for a blank domain, a static guard against carto.token and _getCartoToken returning to any production file, the example carrying neither the field nor the wording while OSM keeps its own, and the absence of any unverifiable claim about key restrictions in either direction. Against 2932368 exactly these four fail: the 64-character label, the over-long value, the value-echoing warning and the "cannot be restricted" claim. Upstream's suite still passes unchanged (33/33). Full frontend sweep over 300 files identical to the 2932368 baseline (157/143).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Since August 2026 CARTO requires a Basemaps API key on raster tile requests. Keyless requests still return
HTTP 200, but the PNG itself is stampedAPI KEY REQUIRED — carto.com/basemapsapikey. Becausecarto-dark/carto-lightare the built-in defaults, every CoreScope instance that has not changed tile providers renders watermarked maps.The
carto.keycontractThe field is named
keyto match upstreamKpa-clawbot/CoreScope#1919, so oneconfig.jsonworks on both this fork and upstream. There is no legacytokenalias — the field was renamed before this branch was ever deployed, and a test assertscarto.tokenis inert and cannot come back.""…/dark_all/{z}/{x}/{y}{r}.png— byte-identical to the pre-key URL"K1"…{r}.png?key=K1" K1 "…{r}.png?key=K1(trimmed)" "(whitespace only)?"a b/c&d"…?key=a%20b%2Fc%26d— encoded exactly oncedomain+keyhttps://{s}.mycompany.cartocdn.com/…?key=K1Config-ready lifecycle
Resolving the key lazily is not enough on its own: Leaflet starts fetching the moment a layer is added, so a keyless first paint would put watermarked tiles in the browser cache before
setUrl()could swap them.MC_whenTileConfigReady(cb)defers the first attach until/api/config/clienthas settled — it waits on settle rather than fulfilment (then(run, run)) with a one-shot guard, so config-success and config-failure each produce exactly one attach and no duplicate tile layers or controls. If no config promise exists at all (standalone pages, older builds, unit tests) the callback runs synchronously, preserving the previous ordering. A config-fetch failure still yields a working keyless layer rather than an empty map.Map and Live use identical semantics.
Every CARTO surface is covered
MC_getCartoTileUrl(path)is the single place a CARTO URL is built. It takes a bare tile path only — a caller cannot smuggle in its own host or querystring — and re-resolves on every call, so config landing later still applies.carto-dark,carto-light,carto-voyager,carto-voyager-dark,positron-dark)map-tile-providers.jsmap.js→MC_whenTileConfigReadylive.js→MC_whenTileConfigReadyTILE_DARK/TILE_LIGHTroles.jsaccessorscustomize-v2.js×2geofilter-builder.htmlA test asserts the only runtime
cartocdnoccurrence left inpublic/is inside_getCartoBase()itself — nothing bypasses the helper.Note on
"enabled": false— it removes CARTO from the registered main-map / layer-picker styles only. The dedicated geo-filter maps call CARTO directly and still needkeyset to avoid the watermark.The key reaches the browser
Like the existing OSM and Stamen tokens, this key is served to the client — unavoidable for raster tiles. The example config says, deliberately without asserting what CARTO does or does not offer:
No real key appears anywhere in this diff; the fixtures are the literal placeholder
YOUR_CARTO_BASEMAP_KEYand obvious fakes.domainvalidation — misconfiguration hardeningdomainis concatenated straight into the host, so an unvalidated value escapes it. This is not an authorization boundary — the value comes from the operator's ownconfig.json— but a typo or a pasted full URL should degrade safely rather than silently retarget tile requests, and the key, at another host:?/#also smuggle a query ahead of the?key=suffix, producing a second?.Accepted: dot-separated DNS labels, trimmed —
mycompany,eu.mycompany,my-company, labels up to 63 characters, whole value up to 238 (keeping"a." + domain + ".cartocdn.com"inside the 253-character host limit).Rejected → falls back to
https://{s}.basemaps.cartocdn.com: schemes,/,?,#, whitespace, empty labels (which covers leading/trailing dots and..), leading/trailing hyphens, and over-long labels or values.Blank/unset is normal and warns not at all. An invalid value logs one warning that names only the expected form — it never reprints the operator's value, which can be of unknown provenance and can end up in shared logs or a screenshot.
Tests
test-carto-basemap-key.js— 74 passed, 0 failed, 5 consecutive clean runs. Coverage: all five styles; missing / empty / whitespace-only key; trimming; URL-encoding and no double-encoding; no key leak into OSM / Esri / Stamen (which keep their owntoken/api_keyspellings); enterprisedomain+keycomposition; querystring smuggling; config before map-init, after map-init, and on fetch failure; automatic layer and layer picker; Map, Live, Customizer and geo-filter builder; exactly one tile attach and one control; every runtimecartocdnoccurrence going through the helper; the full domain-validation matrix including the 63/64-character boundary; the warning not echoing input and firing at most once; and static guards thatcarto.token/_getCartoTokencannot return.Also green: upstream's own
test-issue-1420-tile-providers.js(33/33, unchanged),test-issue-1614-tile-url-function.js,test-issue-1412-customizer-no-override.js,test-geo-filter.js,test-issue-1407-cb-preset-propagation.js.node --check, the builder's inline script, JSON validation,bash -n test-all.shandgit diff --checkall clean.Known baseline failures, unrelated to this branch:
test-issue-1470-node-tile-helper.jsandtest-issue-1438-customizer-mcrole.jsfail identically onmaster. A full frontend sweep over 300 files is otherwise byte-identical tomaster(157 vs 156 pass — the difference is this PR's new test file — and the same 143 pre-existing failures).Comparison with upstream Kpa-clawbot#1919
Upstream's merged fix is 3 files and touches only
BASE_STYLES. Cases this branch covers that it does not: whitespace-only and untrimmed keys (upstream emits?key=%20%20), non-string keys, the four CARTO surfaces outside the registry (which upstream leaves watermarked), the deferred first paint, and thedomainhost-escape — upstream's_getCartoBaseis unvalidated in the same way.Shelf life
CARTO is retiring raster basemaps in favour of vector. This restores the default experience now; a vector migration is separate, larger work that will be needed later.
Staging runs
corescope:pr12-0d504756, built from PR #12 (privacy notice + Priority+ navigation fixes). This branch is based onmaster(f88bf97b) and contains none of that work — verified: nopublic/privacy.js, nocurrentLinks()nav-lifecycle fix, no.nav-rightResizeObserver re-fit, no DPO validation incmd/server/config.go.Deploying PR #13 directly onto the current staging server would produce a regressive tree. Either merge both lines first, or build a combined image, before any staging deploy.